🎖️GitЯра🎖️
Commit 83a778a842822a709c80eacf137fb09d0aab5ae8
Parents : 1b1c85e
Author : simulationstation <32910678+simulationstation@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-11T05:17:12-10:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-11T15:17:12Z
fix(map): scope location tracking to the visible lifecycle (#6602)
Changes
4 files changed, 154 insertions(+), 37 deletions(-)
Diff
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index dd9a89c511..62cbcbe1a0 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -147,6 +147,7 @@ import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop
+import org.meshtastic.core.ui.util.KeepScreenOn
import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.rememberLocationPermissionState
@@ -419,15 +420,7 @@ fun MapView(
}
}
- // Keep screen on while location tracking is active
- LaunchedEffect(myLocationOverlay) {
- val activity = context as? android.app.Activity ?: return@LaunchedEffect
- if (myLocationOverlay != null) {
- activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- } else {
- activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- }
- }
+ KeepScreenOn(myLocationOverlay != null && locationPermission.isGranted)
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 9325777016..6f1d9bf7ed 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -23,7 +23,6 @@ import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap
import android.location.Location
-import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
@@ -158,6 +157,7 @@ import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.TripOrigin
import org.meshtastic.core.ui.theme.TracerouteColors
+import org.meshtastic.core.ui.util.KeepScreenOn
import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.formatPositionTime
@@ -346,28 +346,25 @@ fun MapView(
}
}
- LaunchedEffect(isLocationTrackingEnabled, locationPermission.isGranted) {
- if (isLocationTrackingEnabled && locationPermission.isGranted) {
- val locationRequest =
- LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
- .setMinUpdateIntervalMillis(2000L)
- .build()
- try {
- @Suppress("MissingPermission")
- fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null)
- Logger.d { "Started location tracking" }
- } catch (e: SecurityException) {
- Logger.d { "Location permission not available: ${e.message}" }
- isLocationTrackingEnabled = false
- }
- } else {
+ ActiveWhileStarted(isLocationTrackingEnabled && locationPermission.isGranted) {
+ val locationRequest =
+ LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L).setMinUpdateIntervalMillis(2000L).build()
+ try {
+ @Suppress("MissingPermission")
+ fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null)
+ Logger.d { "Started location tracking" }
+ } catch (e: SecurityException) {
+ Logger.d { "Location permission not available: ${e.message}" }
+ isLocationTrackingEnabled = false
+ }
+
+ val cleanup: () -> Unit = {
fusedLocationClient.removeLocationUpdates(locationCallback)
Logger.d { "Stopped location tracking" }
}
+ cleanup
}
- DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } }
-
// --- Node & waypoint data ---
val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
@@ -585,16 +582,7 @@ fun MapView(
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
- // --- Keep screen on while location tracking ---
- LaunchedEffect(isLocationTrackingEnabled) {
- val activity = context as? Activity ?: return@LaunchedEffect
- val window = activity.window
- if (isLocationTrackingEnabled) {
- window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- } else {
- window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- }
- }
+ KeepScreenOn(isLocationTrackingEnabled && locationPermission.isGranted)
// --- Main UI ---
val isMainMode = mode is GoogleMapMode.Main
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLifecycleEffects.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLifecycleEffects.kt
new file mode 100644
index 0000000000..be8f6302be
--- /dev/null
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLifecycleEffects.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.lifecycle.compose.LifecycleStartEffect
+
+/**
+ * Runs [effect] only while the current lifecycle is at least STARTED and [enabled] is true.
+ *
+ * The cleanup returned by [effect] runs synchronously on ON_STOP, disable, or composition disposal. This is important
+ * for hardware work: a coroutine launched from an ON_STOP state change may not run after the host recomposer pauses.
+ */
+@Composable
+internal fun ActiveWhileStarted(enabled: Boolean, effect: () -> () -> Unit) {
+ val currentEffect by rememberUpdatedState(effect)
+
+ LifecycleStartEffect(enabled) {
+ val cleanup = if (enabled) currentEffect() else ({})
+ onStopOrDispose { cleanup() }
+ }
+}
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/map/MapLifecycleEffectsTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/map/MapLifecycleEffectsTest.kt
new file mode 100644
index 0000000000..4ba72121ef
--- /dev/null
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/map/MapLifecycleEffectsTest.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map
+
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runComposeUiTest
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalTestApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class MapLifecycleEffectsTest {
+
+ @Test
+ fun activeEffectStopsInBackgroundAndResumesOnlyWhenEnabled() = runComposeUiTest {
+ val lifecycleOwner = TestLifecycleOwner(Lifecycle.State.CREATED)
+ var enabled by mutableStateOf(true)
+ var composed by mutableStateOf(true)
+ var starts = 0
+ var stops = 0
+
+ setContent {
+ CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
+ if (composed) {
+ ActiveWhileStarted(enabled) {
+ starts += 1
+ { stops += 1 }
+ }
+ }
+ }
+ }
+
+ assertEquals(0, starts)
+ lifecycleOwner.moveTo(Lifecycle.State.STARTED)
+ waitForIdle()
+ assertEquals(1, starts)
+
+ lifecycleOwner.moveTo(Lifecycle.State.CREATED)
+ waitForIdle()
+ assertEquals(1, stops, "ON_STOP must synchronously release active map work")
+
+ lifecycleOwner.moveTo(Lifecycle.State.STARTED)
+ waitForIdle()
+ assertEquals(2, starts, "ON_START must resume an enabled map tracker")
+
+ runOnIdle { enabled = false }
+ waitForIdle()
+ assertEquals(2, stops, "disabling tracking must release active work")
+
+ lifecycleOwner.moveTo(Lifecycle.State.CREATED)
+ lifecycleOwner.moveTo(Lifecycle.State.STARTED)
+ waitForIdle()
+ assertEquals(2, starts, "a disabled tracker must remain stopped after resume")
+
+ runOnIdle { enabled = true }
+ waitForIdle()
+ assertEquals(3, starts)
+
+ runOnIdle { composed = false }
+ waitForIdle()
+ assertEquals(3, stops, "composition disposal must release active map work")
+ }
+
+ private class TestLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {
+ override val lifecycle: LifecycleRegistry =
+ LifecycleRegistry.createUnsafe(this).apply { currentState = initialState }
+
+ fun moveTo(state: Lifecycle.State) {
+ lifecycle.currentState = state
+ }
+ }
+}
Served by rngit 1.5.0 - Generated in 0.1s